Day 26 我們已經知道:
HTTP Request
↓
Routing
↓
Controller Action
↓
Action Result
↓
HTTP Response
例如:
GET /api/products/10
↓
Routing
↓
GetById(int id)
但還有一個問題:
URL 裡的
10,到底怎麼進入int id?
我們沒有自己寫:
int id = 10;
但 Action 執行時:
id
→ 10
已經準備好了。
這就是今天第一個核心:
Model Binding
另外還會處理兩件事情:
API 應該接收哪些資料?
→ DTO
收到資料之後,
怎麼判斷資料能不能使用?
→ Validation
所以今天主要理解三件事情:
Model Binding:Request Data 怎麼進入 C#?
DTO:API 要接哪些資料?
Validation:這些資料能不能使用?
先看:
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok(id);
}
Request:
GET /api/products/10
Routing 會先找到:
GetById(int id)
但真正執行 Action 前,ASP.NET Core 還要準備:
int id
Route 裡有:
/api/products/10
↑
10
ASP.NET Core 會嘗試把:
"10"
轉成:
int id = 10
這就是 Model Binding。
可以把它理解成:
Model Binding 是 ASP.NET Core 在 Action 執行前,自動取得 HTTP Request Data,並準備成 Action 可以使用的 C# Parameter 或 Object 的機制。
例如:
/api/products/10
→ int id = 10
?keyword=keyboard
→ string keyword = "keyboard"
Request Body:
{
"name": "Keyboard",
"price": 2000
}
則可以建立成:
CreateProductRequest request
Model Binding 發生在真正執行 Action 之前:
HTTP Request
↓
Routing
↓
找到 Action
↓
Model Binding
↓
準備 Parameter / Object
↓
Validation
↓
執行 Action
所以可以記成:
Routing
→ 找到誰來處理
Model Binding
→ 準備 Action 需要的資料
Web API 最常看到三種來源:
| 資料來源 | 範例 | 常見用途 |
|---|---|---|
| Route | /api/products/10 |
Resource Id |
| Query String | ?keyword=keyboard |
搜尋、篩選、排序、分頁 |
| Request Body | JSON | 新增、修改資料 |
例如:
GET /api/products/10
→ int id
GET /api/products/search?keyword=keyboard
→ string? keyword
{
"name": "Keyboard",
"price": 2000
}
可以變成:
CreateProductRequest request
一個 Action 也可以同時使用不同來源:
[HttpPut("{id:int}")]
public IActionResult Update(
int id,
UpdateProductRequest request
)
其中:
id
→ Route
request
→ Request Body
DTO 全名:
Data Transfer Object
可以先理解成:
DTO 用來定義 API 要接收或傳遞哪些資料,也就是 API 的資料邊界。
例如 Product:
public record Product(
int Id,
string Name,
decimal Price
);
代表系統裡完整的 Product。
但建立 Product 時,Client 不一定應該提供:
Id
因為 Id 通常應該由 Server 或 Database 決定。
所以建立 Product 時可以另外定義:
public class CreateProductRequest
{
public string Name { get; set; }
= string.Empty;
public decimal Price { get; set; }
}
這表示:
Create API
允許 Client 提供:
Name
Price
所以:
Product
→ 系統中的資料
CreateProductRequest
→ Create API 接收的資料
DTO 最重要的價值就是明確定義:
API Contract
也就是:
Client 可以傳什麼,API 預期收到什麼。
假設建立 Product 時有以下規則:
Name
→ 必填
→ 最長 100 個字元
Price
→ 1 ~ 1,000,000
可以直接把規則放在 DTO:
using System.ComponentModel.DataAnnotations;
public class CreateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; }
= string.Empty;
[Range(1, 1_000_000)]
public decimal Price { get; set; }
}
例如:
{
"name": "",
"price": -100
}
雖然可以形成:
CreateProductRequest
但內容不符合 Validation Rules。
如果 Controller 使用:
[ApiController]
當 ModelState 無效時,ASP.NET Core Web API 可以自動回傳 400 Bad Request,不需要每個 Action 手動檢查 ModelState.IsValid。
另外要區分:
"abc" → decimal
→ Binding Error
-100 → decimal 成功
但不符合 [Range]
→ Validation Error
前者是資料無法轉成需要的 C# 型別;後者則是型別正確,但內容不符合規則。
到這裡先不要再拆更多概念。
直接看目前這個 Product API 的完整結構會更容易理解。
專案可以先整理成:
MyApi2
│
├─ Controllers
│ └─ ProductsController.cs
│
├─ Models
│ ├─ Product.cs
│ ├─ CreateProductRequest.cs
│ └─ UpdateProductRequest.cs
│
└─ Program.cs
今天主要看三個角色:
Product.cs
→ Product 本身長什麼樣子
CreateProductRequest.cs
→ POST API 可以接收什麼資料
ProductsController.cs
→ HTTP Request 實際怎麼處理
Product.cs檔案:
Models/Product.cs
完整程式碼:
namespace MyApi2.Models;
public record Product(
int Id,
string Name,
decimal Price
);
這個檔案很單純。
它定義:
一個 Product
有哪些資料?
目前有:
Id
Name
Price
例如:
new Product(
1,
"Mouse",
1000m
);
就代表:
Id = 1
Name = Mouse
Price = 1000
CreateProductRequest.cs檔案:
Models/CreateProductRequest.cs
完整程式碼:
using System.ComponentModel.DataAnnotations;
namespace MyApi2.Models;
public class CreateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; }
= string.Empty;
[Range(1, 1_000_000)]
public decimal Price { get; set; }
}
這個檔案不是描述完整 Product。
它描述的是:
Client 要建立 Product 時,可以傳什麼資料。
所以只有:
Name
Price
沒有:
Id
因為目前 Id 由 Server 處理。
而:
[Required]
[StringLength]
[Range]
則負責描述這些輸入資料需要符合哪些規則。
UpdateProductRequest.cs因為 Controller 裡還有 PUT:
[HttpPut("{id:int}")]
public IActionResult Update(
int id,
UpdateProductRequest request
)
所以還需要一個:
Models/UpdateProductRequest.cs
可以寫成:
using System.ComponentModel.DataAnnotations;
namespace MyApi2.Models;
public class UpdateProductRequest
{
[Required]
[StringLength(100)]
public string Name { get; set; }
= string.Empty;
[Range(1, 1_000_000)]
public decimal Price { get; set; }
}
目前 Create 與 Update 的欄位一樣,所以內容看起來很接近。
但它們仍然代表兩個不同 API 的輸入:
CreateProductRequest
→ 建立 Product
UpdateProductRequest
→ 修改 Product
未來需求不同時,兩個 DTO 也可以各自演化。
ProductsController.cs檔案:
Controllers/ProductsController.cs
完整程式碼:
using Microsoft.AspNetCore.Mvc;
using MyApi2.Models;
namespace MyApi2.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static readonly List<Product> Products =
[
new Product(
1,
"Mouse",
1000m
)
];
private static int nextId = 2;
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll()
{
return Ok(Products);
}
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
Product? product =
Products.FirstOrDefault(
product => product.Id == id
);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
[HttpPost]
public ActionResult<Product> Create(
CreateProductRequest request
)
{
Product product =
new(
nextId,
request.Name,
request.Price
);
nextId++;
Products.Add(product);
return CreatedAtAction(
nameof(GetById),
new
{
id = product.Id
},
product
);
}
[HttpPut("{id:int}")]
public IActionResult Update(
int id,
UpdateProductRequest request
)
{
int index =
Products.FindIndex(
product => product.Id == id
);
if (index == -1)
{
return NotFound();
}
Products[index] =
new Product(
id,
request.Name,
request.Price
);
return NoContent();
}
[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
Product? product =
Products.FirstOrDefault(
product => product.Id == id
);
if (product is null)
{
return NotFound();
}
Products.Remove(product);
return NoContent();
}
}
這支 Controller 現在提供:
| HTTP Method | URL | Action |
|---|---|---|
| GET | /api/products |
GetAll() |
| GET | /api/products/{id} |
GetById() |
| POST | /api/products |
Create() |
| PUT | /api/products/{id} |
Update() |
| DELETE | /api/products/{id} |
Delete() |
目前:
List<Product>
只是暫時模擬 Database。
而:
nextId
只是暫時模擬 Database 自動產生 Id。
之後使用 EF Core 時,這些部分會換成真正的 Database 操作。
前面的完整 Controller 先不用全部一次理解。
今天真正要觀察的是:
[HttpPost]
public ActionResult<Product> Create(
CreateProductRequest request
)
{
Product product =
new(
nextId,
request.Name,
request.Price
);
nextId++;
Products.Add(product);
return CreatedAtAction(
nameof(GetById),
new
{
id = product.Id
},
product
);
}
Client 發出:
POST /api/products
Content-Type: application/json
Request Body:
{
"name": "Keyboard",
"price": 2000
}
ASP.NET Core 先找到:
Create(CreateProductRequest request)
接著 Framework 會嘗試把 JSON 準備成:
CreateProductRequest
Name = "Keyboard"
Price = 2000
Validation 通過後,才真正執行 Create()。
因此進到 Action 時:
request.Name
request.Price
都已經可以直接使用。
這一行:
Product product =
new(
nextId,
request.Name,
request.Price
);
把 DTO 裡的資料建立成真正的 Product。
假設:
nextId = 2
最後會得到:
Product
Id = 2
Name = Keyboard
Price = 2000
接著:
nextId++;
只是準備下一個 Id。
再:
Products.Add(product);
把 Product 暫時存進 List<Product>。
最後:
return CreatedAtAction(
nameof(GetById),
new
{
id = product.Id
},
product
);
回傳建立結果。
CreatedAtAction 會建立 201 Created Response,並使用指定的 Action 與 Route Values 產生新 Resource 的位置。
假設建立的是:
Id = 2
Name = Keyboard
Price = 2000
Response:
201 Created
Location: /api/products/2
Body:
{
"id": 2,
"name": "Keyboard",
"price": 2000
}
所以整個 POST 可以理解成:
Client
↓
POST /api/products
↓
JSON
↓
CreateProductRequest
↓
Validation
↓
Create(...)
↓
Product
↓
201 Created
這就是今天最重要的一條線。
最後再看一次最容易混淆的地方。
如果 Client 傳:
{
"name": "Keyboard",
"price": "abc"
}
但:
public decimal Price { get; set; }
"abc" 無法正確轉成 decimal。
這是:
Binding Error
如果 Client 傳:
{
"name": "",
"price": -100
}
可以建立 DTO,但違反:
[Required]
[Range]
這是:
Validation Error
因為 Controller 使用:
[ApiController]
當 ModelState 無效時,ASP.NET Core Web API 可以自動回 400 Bad Request。
今天不用記很多流程圖。
只要看懂這三個檔案:
Product.cs
→ 系統中的 Product
CreateProductRequest.cs
→ POST API 接收的資料與規則
ProductsController.cs
→ Request 實際怎麼被處理
再記住:
HTTP Request
↓
Model Binding
↓
DTO / Parameter
↓
Validation
↓
Action
↓
HTTP Response
最後三個核心:
Model Binding:把 Request Data 準備成 Action 可以使用的 C# 資料。
DTO:定義 API 可以接收或傳遞哪些資料。
Validation:檢查輸入資料是否符合規則。
如果可以從:
POST /api/products
一路看懂:
JSON
↓
CreateProductRequest
↓
Create(...)
↓
Product
↓
201 Created
Day 27 的主要概念就已經掌握了。